Skip to content

feat(tables): add select & multi-select column types#5873

Open
TheodoreSpeaks wants to merge 20 commits into
stagingfrom
feat/table-enum-type
Open

feat(tables): add select & multi-select column types#5873
TheodoreSpeaks wants to merge 20 commits into
stagingfrom
feat/table-enum-type

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

  • Add select (single) and multiselect enum column types to Tables — a column declares a fixed set of options and every cell is constrained to them, rendered as pills
  • Cells store stable option ids (a string for select, string[] for multiselect) so renaming an option never rewrites row data; no DB migration (option set rides in the existing schema JSONB)
  • Server enforces membership and tolerantly maps an option name → its id for tool/import writes; empty/invalid optional cells coerce to null
  • Options are managed in the column-config sidebar; every select cell offers a "None" entry and empty cells read as "None"
  • Options render as neutral grey pills for now — the color field + full palette stay in the model/contract so a picker can be re-added later with no migration
  • Wired into all three edit surfaces (inline cell, expanded popover, row modal); ChipDropdown gains defaultOpen/onOpenChange for inline editing

Known limitations

  • Mothership can't create these columns yet — the copilot tool catalog doesn't expose options (follow-up)

Type of Change

  • New feature

Testing

Tested manually. bun run lint clean, check:api-validation:strict passed, check:react-query passed; 335 table + 14 emcn unit tests green (incl. new validation.test.ts covering membership, name→id coercion, and option-set validation)

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

TheodoreSpeaks and others added 4 commits July 22, 2026 14:31
Adds two enum-style column types where the column declares a fixed set of
options (stable id + name + palette color) and every cell is constrained to
them.

- COLUMN_TYPES gains `select` / `multiselect`; SELECT_COLORS is a fixed,
  theme-aware palette mapping 1:1 to Badge color variants (no raw hex)
- ColumnDefinition.options carries the option set. Cells store option *ids*
  (a string for select, string[] for multiselect) so renaming or recoloring
  an option never rewrites row data
- Row validation enforces membership; coercion tolerantly maps an option
  *name* to its id for tool/import writes and drops unmatched entries
- validateColumnDefinition enforces non-empty, unique-id, unique-name and
  valid-color option sets, and rejects options on non-select columns
- Contract gains selectOptionSchema plus a cross-field refine requiring
  options exactly on select types; routes thread options through type
  changes and a new options-only updateColumnOptions path

No migration: column config already lives in the user_table_definitions
schema JSONB blob.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd
Surfaces the new enum column types in the tables grid.

- New `select-field/` module: SelectPill (colored option via the shared Badge
  palette), SelectValueEditor (one ChipDropdown-backed picker reused by every
  edit surface), SelectOptionsEditor (add/rename/recolor/remove)
- Option colors are picked from inline squircle swatches — no labels, no
  nested dropdown. Each swatch's fill is a Badge in that variant, so the
  palette stays single-sourced and theme-aware
- Cells render option pills; an empty select cell shows a muted "None" so it
  reads as a dropdown. The single-select menu always offers "None" to clear
- Wired into all three edit surfaces (inline cell, expanded popover, row
  modal) plus the type picker and column-type icons
- ChipDropdown gains `defaultOpen`/`onOpenChange` so the inline cell editor
  can open on mount and commit when the menu closes; open state is now
  controlled in both single and multi modes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd
Column auto-resize measured `String(val)` for every non-json/date column,
which for a select cell is the opaque option id (and for multiselect the
comma-joined id array). Selecting auto-fit on a select column therefore
sized it to ids the user never sees. Measure the resolved option names
instead, matching what the pills actually render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd
Removes the per-option swatch picker and defaults every option to the
neutral gray pill. The `color` field stays in the data model and the
SELECT_COLORS palette/contract are untouched, so a picker can be re-added
later as a pure UI change with no migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd
@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 24, 2026 1:54am

Request Review

@cursor

cursor Bot commented Jul 23, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches column PATCH routing, row validation/coercion, and type-conversion compatibility across existing tables; behavior is well-tested but schema JSONB and edit surfaces are broad.

Overview
Introduces a select table column type (single or multiselect) whose allowed choices live in schema JSONB; cells store stable option ids (string or string[]), so renaming options does not rewrite row data.

API & schema: Column create/update routes (app + v1) route real type changes through updateColumnType and option-only edits through new updateColumnOptions, fixing the case where repeating the current type would drop options. Contracts and normalizeColumn now carry options and multiple; option-related errors map to 400s.

Server logic: updateColumnOptions validates option sets and blocks multiselect→single when rows still have multiple selections; type conversion checks values against the target option set. Row validation, coercion (name→id for imports), and column-definition checks enforce membership and option-set rules.

UI: Column sidebar adds Select type, option editor, and multiselect toggle (no unique constraint on select). Grid shows colored pills, inline dropdown editing, row-modal picker; column auto-fit uses option labels. React Query invalidates schema-only on metadata column changes to avoid refetching all rows.

Export & DX: CSV/JSON exports resolve ids to display names; client contract validation errors summarize Zod paths. Grid treats structurally equal cell values (e.g. unchanged multiselect) as no-ops for saves/undo.

Reviewed by Cursor Bugbot for commit 228ba69. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread apps/sim/lib/table/columns/service.ts Outdated
@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds select and multiselect columns to Tables. The main changes are:

  • Stable option IDs with schema and row validation.
  • Option management in the column configuration sidebar.
  • Pill rendering and editing across table views.
  • Compatibility checks for column type conversions.
  • Required-value guards for select editors.

Confidence Score: 5/5

This looks safe to merge.

  • The conversion fix checks stored values against the target option set.
  • Required select and multiselect editors prevent invalid clear actions.
  • No blocking issue remains within the follow-up scope.

Important Files Changed

Filename Overview
apps/sim/lib/table/columns/service.ts Adds option-aware column creation, type conversion checks, and option updates.
apps/sim/lib/table/validation.ts Adds option-set validation, membership checks, and name-to-ID coercion.
apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx Adds shared single-select and multiselect editing with required-value guards.
packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx Adds initial open state and open-state notifications for inline editing.

Reviews (5): Last reviewed commit: "improvement(tables): inline select edit ..." | Re-trigger Greptile

Comment thread apps/sim/lib/table/columns/service.ts Outdated
Addresses review findings on the select/multiselect column types:

- Column type conversion now checks each existing value against the target
  option set (resolve by id or name); a `select`/`multiselect` change is
  blocked when values don't fit, instead of accepting shapes that later
  coercion would strand or silently drop
- Inline select editor discards its draft on Escape (matching the text/date
  editors) rather than committing on menu close
- A required single-select no longer offers "None" — clearing to null could
  never be committed

Exports resolveSelectOptionId from validation for the conversion gate.
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/table/[tableId]/columns/route.ts Outdated
Round 2 review fixes:

- A required multiselect can no longer be emptied — the toggle that would
  remove the last option is ignored, since an empty selection can't be
  committed (server rejects it). Mirrors the required single-select "None"
  guard.
- Inline cell save now compares old vs new value structurally, so a no-op
  edit (e.g. opening a multiselect and closing it unchanged, producing a new
  array reference) no longer writes a row update or pushes an undo entry.
  Uses JSON compare for arrays/objects, matching the existing optionsEqual
  convention; primitives keep the === fast path.
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/table/[tableId]/columns/route.ts Outdated
Round 3 review fixes:

- Expanded-cell popover normalizes a multiselect's initial value with
  toSelectedIds, so a single option-id string (the normal shape right after a
  select->multiselect conversion, before the row is rewritten) is no longer
  collapsed to an empty array and cleared on save.
- Column PATCH now only routes to updateColumnType on a real type change.
  updateColumnType early-returns on an unchanged type, so a payload repeating
  the current type together with new options previously applied neither; an
  unchanged type with options now routes to the options-only update. Fixed in
  both the internal and v1 column routes.
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 4db5e88. Configure here.

- Double-clicking a select/multiselect cell now opens the inline option
  dropdown (like date/number) instead of the fixed-height text popover, which
  rendered just a small dropdown floating in a large empty container. Removes
  the now-unreachable ExpandedSelectEditor from the expanded-cell popover.
- Options editor's add/remove controls match the sibling Filter UI: a ghost,
  muted "Add option" button with a small plus, and an X remove icon.
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

- Collapse `select` + `multiselect` into a single `select` column type with a
  `multiple` boolean. A cell holds one option id when single, an array when
  multiple. Validation/coercion, the contract, routes, and all cell/editor
  code now branch on `column.multiple` instead of a separate type.
- The column-config sidebar exposes an "Allow multiple" toggle (create and
  edit) and no longer offers "Unique" on select columns.
- Give the closed select cell a dropdown affordance: it renders emcn's
  `comboboxVariants` chrome (border + chevron, compacted to the row height),
  with the pills inside and the chevron flipping while the menu is open.
- Single↔multiple toggles reconcile lazily on the next row write (single
  tolerates an array by taking its first resolved option).
Comment thread apps/sim/lib/table/validation.ts
Comment thread apps/sim/lib/table/columns/service.ts
Comment thread apps/sim/lib/table/columns/service.ts
Drop the combobox chrome (border + chevron) from the select cell — the plain
option pills read better. Reverts the comboboxVariants barrel export too, since
nothing else uses it.
fullWidth={fullWidth}
matchTriggerWidth={false}
/>
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty multiselect shows All

Medium Severity

SelectValueEditor sets showAllOption={false} and placeholder='Select options' for multiselect, but ChipDropdown in multiple mode ignores placeholder and renders allLabel (defaulting to All) when the selection is empty. Empty optional multiselect cells in the row modal therefore read as if every option were selected.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d55f94b. Configure here.

…ption

Switching a select column from multiple to single would silently keep only the
first option of any multi-valued cell. updateColumnOptions now scans the rows on
that transition and errors ("N row(s) have multiple options selected") instead
of dropping data, mirroring the type-change compatibility guard.
// Single or multi, every part of the value must resolve to an option.
const parts = Array.isArray(value) ? value : [value]
return parts.every((v) => resolveSelectOptionId(v as JsonValue, targetOptions) !== null)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single-select conversion allows multi-values

Medium Severity

isValueCompatibleWithType for select only checks that every array element resolves to an option. It never consults whether the target is single-select, so a multi-element array is treated as compatible. updateColumnOptions already blocks multi→single for this reason and even comments that it mirrors a type-change guard, but that guard is missing here—so conversion can leave arrays that the next coerce silently reduces to the first id.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 60b15cc. Configure here.

switch (targetType) {
case 'string':
return true
case 'select': {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiselect-to-text drops array data

Medium Severity

Compatibility for target type string returns true for any value, including multiselect string[] cells. Changing a multiselect column to text therefore succeeds while leaving arrays in row data; the next coerce cannot turn those arrays into strings and nulls optional cells (or fails required ones), so selections are lost without the conversion error used for number/date/boolean.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 60b15cc. Configure here.

return JSON.stringify(a) === JSON.stringify(b)
}
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty multiselect false change

Medium Severity

Untouched multiselect cells are stored as null, but the inline editor always commits [] for an empty selection. cellValuesEqual does not treat null and [] as equal, so opening and dismissing an empty multiselect still counts as a change, writes a row update, and pushes an undo entry.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d06c399. Configure here.

...(typeChanged ? { type: typeInput } : {}),
...(uniqueChanged ? { unique: uniqueInput } : {}),
...(wantsOptions && (typeChanged || optionsChanged) ? { options: trimmedOptions } : {}),
...(wantsOptions && (typeChanged || multipleChanged) ? { multiple: multipleInput } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unique stuck on select convert

Low Severity

Converting a unique column to select hides the Unique control and suppresses uniqueChanged, and the save payload never clears unique. A previously unique column therefore keeps its unique constraint after becoming select, with no way to turn it off while the type remains select.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d06c399. Configure here.

…pills

When an option is deleted, cells that referenced it previously rendered a gray
fallback pill labeled with the raw internal id. They now drop the orphaned id
and fall back to empty ("None"). Editors seed their selection from the still-
valid ids too, so editing/saving such a cell writes the cleaned value.
Comment thread apps/sim/lib/table/columns/service.ts
onChange={(ids) => {
if (column.required && ids.length === 0) return
onChange(ids)
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty multiselect breaks isEmpty filters

Medium Severity

Cleared multiselect cells are stored as [] (editors and coerce), which the UI shows as None, but $empty only treats JSON null/absent/empty string as empty. Filtered “is empty” views therefore miss cleared multiselect cells, and “is not empty” includes them.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3e0b68a. Configure here.

Match the sub-block list editors (filter/sort builders): a full-width,
dashed-border ghost "Add option" button instead of a plain text button.
…opy log

- Add/update column are metadata-only server-side (row data never changes), so
  they now invalidate just the schema, not the rows. Saving a column (e.g.
  editing select options) no longer triggers a full rows refetch/flash; cells
  re-render from the refetched schema. Delete-column and workflow-group ops keep
  the rows invalidation since they do change row data.
- "Failed to copy rows" logged an empty {} for DOMExceptions; log the extracted
  message so the real reason (e.g. lost transient activation) is visible.
- Tables created before the select/multiselect merge stored `type:
  'multiselect'`, which the removed type no longer accepts — every response
  carrying such a column failed contract validation and column edits threw.
  getTableById now maps `multiselect` → `select` + `multiple: true` on read
  (covers reads, column ops via withLockedTable, and their responses); the
  migrated shape persists on the table's next schema write.
- requestJson's "Response failed contract validation" now appends a short
  "field: reason" summary of the Zod issues instead of a bare message, so the
  failing field is visible.
Only local dev tables ever held the removed `multiselect` type (the feature
never shipped), so the read-time backward-compat mapping isn't warranted.
Keeps the readable contract-validation error from the same change.
Replace the "Add option" button with a trailing empty row: the first keystroke
materializes the option and focus jumps into it (cursor at end) so typing flows
straight through. Enter in an option jumps back to the trailing row to add the
next one. Removing a row is unchanged.
const id = resolveSelectOptionId(entry, options)
if (id !== null && !ids.includes(id)) ids.push(id)
}
return { ok: true, value: ids }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiselect clipboard round-trip clears cells

High Severity

Multiselect cells copy as a JSON array string, but paste/coercion never parses that string back into ids. coerceValueToColumnType wraps the whole blob as one entry, resolves nothing, and stores [], so copy-paste (and CSV re-import) silently clears multiselect values.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0c465c8. Configure here.

CSV/JSON export serialized the raw stored value for select cells — an option
id (or an array of ids for multi) — so downloads showed opaque `opt_…` ids.
Export now resolves ids to option names: CSV comma-joins multi names; JSON
resolves values before the id→name key translation. Ids with no matching
option (deleted) are dropped.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 9 total unresolved issues (including 8 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 228ba69. Configure here.

return neutralizeCsvFormula(text)
}
return formatCsvValue(value)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sync export leaves select IDs raw

High Severity

formatCsvCell / resolveSelectExportValue were added so exports show option names, and the async export runner uses them, but the synchronous /export route still formats cells with raw stored values. Tables under the async threshold therefore export opaque option ids (and unresolved JSON select values) instead of labels, so the two export paths no longer match.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 228ba69. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant